import path from 'node:path'

import { type ExecaChildProcess, execaNode } from 'execa'
import fs from 'fs-jetpack'
import type { FSJetpack, InspectTreeResult } from 'fs-jetpack/types'
import tempy from 'tempy'

/**
 * Base test context.
 */
export type BaseContext = {
  tmpDir: string
  fs: FSJetpack
  mocked: {
    cwd: string
  }
  /**
   * Set up the temporary directory based on the contents of some fixture.
   */
  fixture: (name: string) => void
  /**
   * Spawn the Prisma cli using the temporary directory as the CWD.
   *
   * @remarks
   *
   * For this to work the source must be built
   */
  cli: (...input: string[]) => ExecaChildProcess<string>

  printDir(dir: string, extensions: string[]): void
  /**
   * JavaScript-friendly implementation of the `tree` command. It skips the `node_modules` directory.
   * @param itemPath The path to start the tree from, defaults to the root of the temporary directory
   * @param indent How much to indent each level of the tree, defaults to ''
   * @returns String representation of the directory tree
   */
  tree: (itemPath?: string, indent?: string) => void
}

/**
 * Create test context to use in tests. Provides the following:
 *
 * - A temporary directory
 * - an fs-jetpack instance bound to the temporary directory
 * - Mocked process.cwd via Node process.chdir
 * - Fixture loader for bootstrapping the temporary directory with content
 */
export const jestContext = {
  new: function (ctx: BaseContext = {} as any) {
    const c = ctx as BaseContext

    beforeEach(() => {
      const originalCwd = process.cwd()

      c.mocked = c.mocked ?? {
        cwd: process.cwd(),
      }

      c.tmpDir = tempy.directory()
      c.fs = fs.cwd(c.tmpDir)
      c.tree = (startFrom = c.tmpDir, indent = '') => {
        function* generateDirectoryTree(children: InspectTreeResult[], indent = ''): Generator<String> {
          for (const child of children) {
            if (child.name === 'node_modules' || child.name === '.git') {
              continue
            }

            if (child.type === 'dir') {
              yield `${indent}└── ${child.name}/`
              yield* generateDirectoryTree(child.children, indent + '    ')
            } else if (child.type === 'symlink') {
              yield `${indent} -> ${child.relativePath}`
            } else {
              yield `${indent}└── ${child.name}`
            }
          }
        }

        const children = c.fs.inspectTree(startFrom, { relativePath: true, symlinks: 'report' })?.children || []

        return `
${[...generateDirectoryTree(children, indent)].join('\n')}
`
      }

      c.fixture = (name: string) => {
        // copy the specific fixture directory in isolated tmp directory
        c.fs.copy(path.join(originalCwd, 'src', '__tests__', 'fixtures', name), '.', {
          overwrite: true,
        })
        // symlink to local client version in tmp dir
        c.fs.symlink(path.join(originalCwd, '..', 'client'), path.join(c.fs.cwd(), 'node_modules', '@prisma', 'client'))
        c.fs.symlink(path.join(originalCwd, '..', 'config'), path.join(c.fs.cwd(), 'node_modules', '@prisma', 'config'))
      }

      c.cli = (...input) => {
        return execaNode(path.join(originalCwd, '../cli/build/index.js'), input, {
          cwd: c.fs.cwd(),
          stdio: 'pipe',
          all: true,
        })
      }
      c.printDir = (dir, extensions) => {
        const content = c.fs.list(dir) ?? []
        content.sort((a, b) => a.localeCompare(b))
        return content
          .filter((name) => extensions.includes(path.extname(name)))
          .map((name) => `${name}:\n\n${c.fs.read(path.join(dir, name))}`)
          .join('\n\n')
      }
      process.chdir(c.tmpDir)
    })

    afterEach(() => {
      process.chdir(c.mocked.cwd)
    })

    return factory(ctx)
  },
}

/**
 * A function that provides additional test context.
 */
type ContextContributor<Context, NewContext> = (ctx: Context) => Context & NewContext

/**
 * Main context builder API that permits recursively building up context.
 */

function factory<Context>(ctx: Context) {
  return {
    add<NewContext>(contextContributor: ContextContributor<Context, NewContext>) {
      const newCtx = contextContributor(ctx)
      return factory<Context & NewContext>(newCtx)
    },
    assemble(): Context {
      return ctx
    },
  }
}

/**
 * Test context contributor. Mocks console.error with a Jest spy before each test.
 */

type ConsoleContext = {
  mocked: {
    'console.error': jest.SpyInstance
    'console.log': jest.SpyInstance
    'console.info': jest.SpyInstance
    'console.warn': jest.SpyInstance
  }
}

export const jestConsoleContext =
  <Ctx extends BaseContext>() =>
  (c: Ctx) => {
    const ctx = c as Ctx & ConsoleContext

    beforeEach(() => {
      ctx.mocked['console.error'] = jest.spyOn(console, 'error').mockImplementation(() => {})
      ctx.mocked['console.log'] = jest.spyOn(console, 'log').mockImplementation(() => {})
      ctx.mocked['console.info'] = jest.spyOn(console, 'info').mockImplementation(() => {})
      ctx.mocked['console.warn'] = jest.spyOn(console, 'warn').mockImplementation(() => {})
    })

    afterEach(() => {
      ctx.mocked['console.error'].mockRestore()
      ctx.mocked['console.log'].mockRestore()
      ctx.mocked['console.info'].mockRestore()
      ctx.mocked['console.warn'].mockRestore()
    })

    return ctx
  }

/**
 * Test context contributor. Mocks process.std(out|err).write with a Jest spy before each test.
 */

type ProcessContext = {
  mocked: {
    'process.stderr.write': jest.SpyInstance
    'process.stdout.write': jest.SpyInstance
  }
  normalizedCapturedStdout: () => string
  normalizedCapturedStderr: () => string
  clearCapturedStdout: () => void
  clearCapturedStderr: () => void
}

type NormalizationRule = [RegExp | string, string]

export type ProcessContextSettings = {
  normalizationRules: NormalizationRule[]
}

export const jestStdoutContext =
  <Ctx extends BaseContext>({ normalizationRules }: ProcessContextSettings = { normalizationRules: [] }) =>
  (c: Ctx) => {
    const ctx = c as Ctx & ProcessContext

    const normalize = (text: string, rules: NormalizationRule[]) => {
      for (const [pattern, replacement] of rules) {
        text = text.replace(pattern, replacement)
      }
      return text
    }

    beforeEach(() => {
      ctx.mocked['process.stderr.write'] = jest.spyOn(process.stderr, 'write').mockImplementation(() => true)
      ctx.mocked['process.stdout.write'] = jest.spyOn(process.stdout, 'write').mockImplementation(() => true)
      ctx.normalizedCapturedStdout = () =>
        normalize(ctx.mocked['process.stdout.write'].mock.calls.join(''), normalizationRules)
      ctx.normalizedCapturedStderr = () =>
        normalize(ctx.mocked['process.stderr.write'].mock.calls.join(''), normalizationRules)
      ctx.clearCapturedStdout = () => ctx.mocked['process.stdout.write'].mockClear()
      ctx.clearCapturedStderr = () => ctx.mocked['process.stderr.write'].mockClear()
    })

    afterEach(() => {
      ctx.mocked['process.stderr.write'].mockRestore()
      ctx.mocked['process.stdout.write'].mockRestore()
    })

    return ctx
  }

/**
 * Test context contributor. Mocks process.exit with a spay and records the exit code.
 */

type ProcessExitContext = {
  mocked: {
    'process.exit': jest.SpyInstance
  }
  recordedExitCode: () => number
}

export const processExitContext =
  <C extends BaseContext>() =>
  (c: C) => {
    const ctx = c as C & ProcessExitContext

    beforeEach(() => {
      ctx.mocked['process.exit'] = jest.spyOn(process, 'exit').mockImplementation((number) => {
        throw new Error('process.exit: ' + number)
      })
      ctx.recordedExitCode = () => ctx.mocked['process.exit'].mock.calls[0]?.[0]
    })

    afterEach(() => {
      ctx.mocked['process.exit'].mockRestore()
    })

    return ctx
  }
